Skip to content

feat(mcp): preserve CallToolResult.isError flag in MCPToolResult#2118

Open
Zelys-DFKH wants to merge 2 commits intostrands-agents:mainfrom
Zelys-DFKH:fix/mcp-preserve-is-error-flag
Open

feat(mcp): preserve CallToolResult.isError flag in MCPToolResult#2118
Zelys-DFKH wants to merge 2 commits intostrands-agents:mainfrom
Zelys-DFKH:fix/mcp-preserve-is-error-flag

Conversation

@Zelys-DFKH
Copy link
Copy Markdown

Description

Adds an optional isError field to MCPToolResult, set to True only when the underlying MCP tool returned CallToolResult.isError=True. This lets callers tell apart application-level errors (the tool ran but reported a failure) from protocol/transport errors (the tool never ran because of a client exception, timeout, or session issue). Right now both surface identically as status="error", which is what #1670 is asking to fix.

Behavior:

  • Tool-reported error (CallToolResult.isError=True): result has status="error" and isError=True
  • Protocol/client exception: result has status="error" and no isError key. This path goes through _handle_tool_execution_error and is unchanged.
  • Successful call: status="success", no isError key

The field is NotRequired, so existing code reading MCPToolResult keeps working without changes.

Related Issues

Closes #1670

Documentation PR

N/A

Type of Change

New feature

Testing

How have you tested the change? Verify that the changes do not break functionality or introduce warnings in consuming repositories: agents-docs, agents-tools, agents-cli

  • Extended the existing test_call_tool_sync_status and test_call_tool_async_status parametrized tests to assert result.get("isError") == (True if is_error else None)
  • Extended test_mcp_tool_result_type to confirm the field is absent by default and settable when provided
  • Ran hatch run prepare (ruff + mypy + full test suite): 2524 passed, 4 skipped, no new warnings

No new docs needed — this is a small additive field on an existing TypedDict, already documented in the docstring on MCPToolResult.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Adds an optional isError field to MCPToolResult, set to True only when
the underlying MCP tool returned CallToolResult.isError=True. This lets
callers tell apart application-level errors from protocol/transport
errors, which currently both surface as status='error'.

Closes strands-agents#1670
@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Apr 15, 2026

/strands review

@mkmeral mkmeral self-assigned this Apr 15, 2026

structuredContent: NotRequired[dict[str, Any]]
metadata: NotRequired[dict[str, Any]]
isError: NotRequired[bool]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The type NotRequired[bool] allows both True and False as values, but per the implementation and docstring contract, only True is ever set (and the key is absent otherwise). Using NotRequired[Literal[True]] would encode the actual contract in the type system — the field is either absent or True, never False.

Suggestion: Consider narrowing the type to NotRequired[Literal[True]] to make the semantics explicit:

from typing import Literal
isError: NotRequired[Literal[True]]

This helps callers understand they can simply check if result.get("isError") without worrying about an explicit False value.

@github-actions
Copy link
Copy Markdown

Assessment: Comment (leaning Approve)

Clean, well-scoped change that correctly addresses #1670 by preserving the MCP isError flag. The implementation follows existing patterns (structuredContent, metadata) and the NotRequired field aligns with the "pay for play" decision record — existing callers are unaffected.

Review Details
  • Type precision: The field is typed as NotRequired[bool] but only True is ever set (absent otherwise). NotRequired[Literal[True]] would encode the actual contract more precisely.
  • Test assertions: The assertions for the absent-key case could more clearly express the documented behavior by checking key absence rather than comparing to None.

The docstring clearly explains the three-way semantics (tool error vs. protocol error vs. success), and the change is minimal and well-tested. Nice contribution! 👍

# No structured content should be present when not provided by MCP
assert result.get("structuredContent") is None
# isError flag should be True only when the tool reported an application error
assert result.get("isError") == (True if is_error else None)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The assertion result.get("isError") == (True if is_error else None) works but is slightly unclear — when is_error=False, it's asserting None == None, which doesn't clearly communicate that the key should be absent.

Suggestion: For the is_error=False case, explicitly assert key absence to match the documented contract:

if is_error:
    assert result.get("isError") is True
else:
    assert "isError" not in result

This more directly tests the documented behavior (key absent on success vs. True on error).

Comment thread src/strands/tools/mcp/mcp_client.py Outdated
result["structuredContent"] = call_tool_result.structuredContent
if call_tool_result.meta:
result["metadata"] = call_tool_result.meta
if call_tool_result.isError:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should check isError != None so we pass through false values too

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Updated to if call_tool_result.isError is not None: result["isError"] = call_tool_result.isError so both True and False come through. The key is only absent when a protocol/client exception bypasses _handle_tool_result entirely. Tests updated to match.

Per maintainer feedback: use 'if call_tool_result.isError is not None'
so both True and False are preserved in the result. The field is absent
only when a protocol/client exception bypasses _handle_tool_result.
Updated test assertions to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Preserve CallToolResult.isError flag in MCPToolResult to distinguish application errors from protocol errors

2 participants